열거형과 패턴 매칭 (Enum & Match)

NOTE

Rust의 enum이 C의 enum(단순 정수 상수)과 근본적으로 다른 이유(각 variant가 데이터를 가질 수 있는 대수적 데이터 타입, ADT)부터, Option<T>로 null을 대체하는 이유, match/if let 기초 문법을 정리한 뒤, GlueSQL이 SQL 문장·표현식·값을 어떻게 enum으로 모델링하는지 실전 코드로 확인.

📌 개념

기본 enum 정의 — variant마다 다른 데이터

enum IpAddrKind {
    V4,
    V6,
}
 
// variant가 자신만의 데이터를 가질 수 있음 (tuple-like variant)
enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
 
// variant마다 완전히 다른 형태의 데이터를 가질 수도 있음
enum Message {
    Quit,                       // 데이터 없음 (unit variant)
    Move { x: i32, y: i32 },    // 이름 있는 필드 (struct-like variant)
    Write(String),              // 값 하나 (tuple-like variant)
    ChangeColor(i32, i32, i32), // 값 여러 개
}

C의 enum은 정수 상수 이름의 집합일 뿐이라 Message 같은 구조를 표현하려면 union + 태그 필드를 직접 조합해야 한다(→ Union, Typedef, Struct 노트의 Tagged Union이 바로 그 수작업 버전). Rust는 이걸 enum 하나로 컴파일러가 안전하게 보장해준다 — 한 시점에는 정확히 하나의 variant만 존재하고, 그 variant에 맞는 데이터만 꺼낼 수 있음이 타입 시스템으로 강제됨.

NOTE

:, ->, =>, = 같은 기호 구분과 String vs &str은 별도 노트로 분리했다 — (Rust) 기호 완전정리(콜론·화살표·등호, String vs str) - 핵심 개념 및 특징 정리 참고.

enum에도 메서드를 붙일 수 있다 — impl

구조체와 마찬가지로 impl 블록으로 enum에 메서드를 추가할 수 있다.

impl Message {
    fn call(&self) {
        match self {
            Message::Quit => println!("종료"),
            Message::Move { x, y } => println!("이동: {x}, {y}"),
            Message::Write(text) => println!("쓰기: {text}"),
            Message::ChangeColor(r, g, b) => println!("색상: {r},{g},{b}"),
        }
    }
}

Option<T> — null 없는 언어에서 “값이 없음”을 표현하는 법

Rust에는 null이 없다. 대신 표준 라이브러리가 제공하는 제네릭 열거형 Option<T>로 “값이 있을 수도, 없을 수도 있음”을 명시적으로 표현한다.

enum Option<T> {
    Some(T),
    None,
}
 
let some_number = Some(5);
let absent_number: Option<i32> = None;
  • Option<T>T(예: Option<i32>i32)는 서로 다른 타입이라, Some(5) + 1처럼 바로 연산할 수 없다. 반드시 matchif let으로 Some/None을 먼저 구분한 뒤에야 안의 값을 꺼내 쓸 수 있음
  • 이 강제성 덕분에 “null인 줄 모르고 접근해서 터지는” NullPointerException류의 버그가 컴파일 타임에 차단됨

match 기초 — 모든 경우를 빠짐없이 처리

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
  • match표현식(expression) 이라 각 갈래의 결과값이 바로 함수의 반환값이 될 수 있음 (마지막에 return 불필요 — 소유권과 참조 노트의 표현식 기반 반환과 같은 원리)
  • Exhaustiveness (전수 검사): enum의 모든 variant를 빠짐없이 처리해야 컴파일된다. 나중에 Coin에 variant를 추가하면, 이를 처리하지 않는 모든 match 구문에서 컴파일 에러가 발생해 “빠뜨린 곳”을 컴파일러가 알려줌 — C의 switch(variant를 빠뜨려도 조용히 컴파일됨)와 가장 큰 차이점
  • 처리하지 않는 나머지 경우는 _ => ...로 한 번에 받을 수 있음

match 가드(guard)와 값 바인딩(@)

let num = 4;
match num {
    x if x < 0 => println!("음수"),
    x if x % 2 == 0 => println!("짝수: {x}"),
    x => println!("홀수: {x}"),
}
 
// @ 바인딩: 패턴이 맞는지 검사하면서 동시에 그 값을 변수로 사용
match num {
    n @ 1..=5 => println!("1~5 사이의 값: {n}"),
    _ => println!("범위 밖"),
}

if let / while let — match 축약형

match 갈래가 하나만 관심 대상이고 나머지는 무시하고 싶을 때 더 간결하게 쓰는 문법.

let config_max = Some(3u8);
 
// match로 쓰면
match config_max {
    Some(max) => println!("최댓값: {max}"),
    _ => (),
}
 
// if let으로 축약 (동일한 의미)
if let Some(max) = config_max {
    println!("최댓값: {max}");
}

재귀적 enum과 Box

enum의 variant가 자기 자신을 다시 담아야 하는 경우(연결 리스트 등), 크기가 무한히 커지는 문제 때문에 Box로 감싸야 한다.

enum List {
    Cons(i32, Box<List>), // Box로 감싸 힙에 고정 크기(포인터)로 저장
    Nil,
}
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));

GlueSQL 실전 예제

GlueSQL은 SQL 문장·표현식·값(Value)을 모두 위에서 배운 enum으로 모델링하고 match로 분기 처리한다.

SQL 문장을 표현하는 Statement enum

// core/src/ast.rs:48-142 (일부 축약)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Statement {
    ShowColumns { table_name: String },
    Query(Query),
    Insert { table_name: String, columns: Vec<String>, source: Query },
    Update { table_name: String, assignments: Vec<Assignment>, selection: Option<Expr> },
    Delete { table_name: String, selection: Option<Expr> },
    CreateTable { if_not_exists: bool, name: String, columns: Option<Vec<ColumnDef>> /* .. */ },
    AlterTable { name: String, operation: AlterTableOperation },
    DropTable { if_exists: bool, names: Vec<String>, cascade: bool },
}

각 variant가 서로 다른 필드 구조를 가짐: ShowColumns는 문자열 하나, Insert는 3개 필드를 가진 struct-like variant, Query는 다른 타입 하나를 감싸는 tuple-like variant. selection: Option<Expr>처럼 “WHERE 절이 있을 수도, 없을 수도 있음”을 표현하는 데도 Option이 쓰임.

SQL 표현식을 표현하는 Expr enum (재귀적 구조)

// core/src/ast/expr.rs:11-86 (일부 축약)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Expr {
    Identifier(String),
    CompoundIdentifier { alias: String, ident: String },
    IsNull(Box<Expr>),
    InList { expr: Box<Expr>, list: Vec<Expr>, negated: bool },
    BinaryOp { left: Box<Expr>, op: BinaryOperator, right: Box<Expr> },
    Nested(Box<Expr>),
    Literal(Literal),
    Function(Box<Function>),
    Case { operand: Option<Box<Expr>>, when_then: Vec<(Expr, Expr)>, else_result: Option<Box<Expr>> },
    Array { elem: Vec<Expr> },
}

BinaryOp처럼 Expr variant가 다시 Expr을 담아야 하는 재귀적 타입은 위 List/Cons 예제와 똑같은 이유로 Box<Expr>로 감싼다 (a + b * c 같은 중첩 표현식 트리를 표현하려면 필수).

값(Value)을 표현하는 Value enum

// core/src/data/value.rs:42-70 (일부 축약)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Value {
    Bool(bool), I8(i8), I16(i16), I32(i32), I64(i64), I128(i128),
    U8(u8), U16(u16), U32(u32), U64(u64), U128(u128),
    F32(f32), F64(f64), Decimal(Decimal), Str(String), Bytea(Vec<u8>),
    Inet(IpAddr), Date(NaiveDate), Timestamp(NaiveDateTime), Time(NaiveTime),
    Interval(Interval), Uuid(u128), Map(BTreeMap<String, Value>),
    List(Vec<Value>), Point(Point), Null,
}

SQL의 동적 타입 시스템(한 컬럼에 여러 타입이 올 수 있음)을 표현하기 위한 대표적인 “하나의 값, 여러 가능한 타입” 패턴. Null variant가 별도로 있는 것에 주목 — SQL의 NULL은 “값이 없는 상태”인데, 흥미롭게도 이 경우엔 Option<Value>가 아니라 Value::Null이라는 variant로 직접 표현했다(SQL 값 자체에 NULL이 포함되는 게 자연스럽기 때문).

match로 enum 분기 처리

// core/src/executor/evaluate.rs:60-88 (일부 축약)
match expr {
    ExprPlan::Literal(literal) => Ok(expr::literal(literal)),
    ExprPlan::Value(value) => Ok(Evaluated::Value(Cow::Borrowed(value))),
    ExprPlan::TypedString { data_type, value } => expr::typed_string(data_type, value),
    ExprPlan::Identifier(ident) => {
        let context = context
            .ok_or_else(|| EvaluateError::IdentifierRequiresRowContext(ident.to_owned()))?;
        match context.get_value(ident) {
            Some(value) => Ok(Evaluated::Value(Cow::Owned(value.clone()))),
            None => Err(EvaluateError::IdentifierNotFound(ident.to_owned()).into()),
        }
    }
}

variant에 담긴 데이터를 match 패턴 안에서 바로 꺼내 쓸 수 있음 (ExprPlan::Literal(literal)에서 literal 변수 바로 사용). 안쪽의 context.get_value(ident)Option<Value>를 반환하므로, 앞서 배운 Some/None 매칭이 그대로 적용됨.

튜플 패턴 매칭 — evaluate_eq

// core/src/data/value.rs:83-108 (일부 축약)
pub fn evaluate_eq(&self, other: &Value) -> Tribool {
    use Value::*;
    match (self, other) {
        (Null, _) | (_, Null) => Tribool::Null,
        (I8(l), _) => Tribool::from(l == other),
        (Date(l), Timestamp(r)) => Tribool::from(
            l.and_hms_opt(0, 0, 0).is_some_and(|date_time| &date_time == r),
        ),
        _ => Tribool::from(self == other),
    }
}
  • match (self, other): 두 값을 튜플로 묶어 두 enum의 조합에 대해 패턴 매칭 (Rust는 튜플도 패턴으로 분해 가능)
  • (Null, _) | (_, Null): |로 여러 패턴을 OR 조건처럼 하나의 분기에 묶음
  • _: “나머지 모든 경우” — C의 default와 유사하지만 반드시 마지막에 두어 예외적으로 처리 안 된 조합을 잡아냄

일반적인 enum 사용 vs GlueSQL 스타일 enum 사용 — 비교 및 연습

교과서 예제(Coin, Message, List)와 GlueSQL 실전 코드(Statement, Expr, Value)는 같은 enum 문법을 쓰지만 “왜 이렇게 설계했는가”에서 차이가 크다. 연습 삼아 그 차이를 표로 정리하고, GlueSQL 스타일을 흉내 낸 미니 예제를 직접 만들어본다.

구분일반적인(교과서) 사용GlueSQL 스타일 사용
variant 데이터값 하나 정도로 단순 (Message::Write(String))struct-like variant에 필드 여러 개(Insert { table_name, columns, source })가 흔함 — 실제 도메인 데이터가 복잡하기 때문
재귀 구조학습용으로 List/Cons 하나만 다룸SQL 표현식 트리 전체가 재귀 enum(Expr::BinaryOp { left: Box<Expr>, .. })으로 구성 — 실제 “트리 구조 데이터”를 표현할 때 재귀 enum이 필요함을 보여줌
Option 사용”값의 유무” 자체를 배우는 데 집중selection: Option<Expr>처럼 “이 필드는 있을 수도, 없을 수도 있는 선택 사항”이라는 도메인 의미를 타입으로 드러냄 (WHERE 절 유무)
derive 개수학습 예제는 대개 derive 없이 진행실무 enum엔 Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize 등을 거의 항상 붙임 — 비교/복제/직렬화가 실제로 필요해서
match 대상값 하나만 매칭match (self, other)처럼 튜플로 묶어 두 enum의 조합을 한 번에 매칭하기도 함(evaluate_eq)
null 표현Option<T>::None으로 통일도메인상 “없음”도 값의 하나로 자연스러우면 Option 대신 자체 Null variant를 둠(Value::Null) — SQL의 NULL이 값 자체의 일부이기 때문

연습 예제 1 — 재귀 enum으로 수식 트리 만들기 (Expr::BinaryOp 흉내)

GlueSQL의 Expr::BinaryOp { left: Box<Expr>, op, right: Box<Expr> } 구조를 그대로 축소해서, (2 + 3) * -4 같은 중첩 수식을 표현하고 계산하는 연습.

#[derive(Debug, Clone, PartialEq)]
enum Expr {
    Num(i32),
    Neg(Box<Expr>),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
}
 
impl Expr {
    fn eval(&self) -> i32 {
        match self {
            Expr::Num(n) => *n,
            Expr::Neg(e) => -e.eval(),
            Expr::Add(l, r) => l.eval() + r.eval(),
            Expr::Mul(l, r) => l.eval() * r.eval(),
        }
    }
}
 
fn main() {
    // (2 + 3) * -4
    let expr = Expr::Mul(
        Box::new(Expr::Add(Box::new(Expr::Num(2)), Box::new(Expr::Num(3)))),
        Box::new(Expr::Neg(Box::new(Expr::Num(4)))),
    );
    println!("{:?} = {}", expr, expr.eval()); // -20
}
  • Neg/Add/Mul이 자기 자신(Expr)을 다시 담으므로 Box로 감싸야 크기가 고정됨 — 앞서 배운 List/Cons와 같은 이유
  • eval()match 안에서 재귀적으로 자기 자신을 호출(l.eval(), r.eval()) — GlueSQL의 evaluate.rsExpr 트리를 실제 값으로 계산하는 방식과 동일한 패턴

연습 예제 2 — struct-like variant + Option 필드 (selection: Option<Expr> 흉내)

GlueSQL의 Update/Delete variant가 selection: Option<Expr>로 “WHERE 절이 있을 수도 없을 수도 있음”을 표현하는 방식을 그대로 연습.

#[derive(Debug)]
enum Command {
    Select { table: String, condition: Option<Expr> },
    Delete { table: String, condition: Option<Expr> },
}
 
fn describe(cmd: &Command) {
    match cmd {
        Command::Select { table, condition: Some(cond) } => {
            println!("SELECT FROM {table} WHERE {cond:?}")
        }
        Command::Select { table, condition: None } => {
            println!("SELECT FROM {table} (조건 없음)")
        }
        Command::Delete { table, condition } => {
            println!("DELETE FROM {table}, 조건: {condition:?}")
        }
    }
}
 
fn main() {
    describe(&Command::Select { table: "users".into(), condition: Some(Expr::Num(1)) });
    describe(&Command::Select { table: "users".into(), condition: None });
}
  • match 패턴 안에서 condition: Some(cond) / condition: None처럼 struct-like variant의 필드까지 한 번에 분해 가능 — GlueSQL의 context.get_value(ident) 매칭(Some(value) / None)과 같은 원리
  • Select variant를 조건 유무에 따라 두 갈래로 나눠 처리 vs Deletecondition 그대로 넘겨 {:?}로 출력 — 실무에서는 이렇게 필드를 “분해해서 쓸지, 통째로 넘길지”를 상황에 맞게 섞어 씀

🔗 참고